Um mergulho profundo no hook useSyncExternalStore do React para sincronizar armazenamentos de dados externos, incluindo estratégias de implementação e casos de uso avançados.
React useSyncExternalStore: Dominando a Sincronização de Stores Externos
Em aplicações React modernas, gerenciar o estado de forma eficaz é crucial. Embora o React forneça soluções de gerenciamento de estado integradas como useState e useReducer, a integração com fontes de dados externas ou bibliotecas de gerenciamento de estado de terceiros requer uma abordagem mais sofisticada. É aqui que entra o useSyncExternalStore.
O que é useSyncExternalStore?
useSyncExternalStore é um hook do React, introduzido no React 18, que permite que você se inscreva e leia dados de fontes externas de uma forma compatível com a renderização concorrente. Isso é particularmente importante ao lidar com dados que não são gerenciados diretamente pelo React, como:
- Bibliotecas de gerenciamento de estado de terceiros: Redux, Zustand, Jotai, etc.
- APIs do navegador:
localStorage,IndexedDB, etc. - Fontes de dados externas: Server-sent events, WebSockets, etc.
Antes do useSyncExternalStore, a sincronização de stores externos podia levar a "tearing" (rasgos) e inconsistências, especialmente com os recursos de renderização concorrente do React. Este hook resolve esses problemas fornecendo uma maneira padronizada e performática de conectar dados externos aos seus componentes React.
Por que usar o useSyncExternalStore? Benefícios e Vantagens
Usar o useSyncExternalStore oferece várias vantagens importantes:
- Segurança na Concorrência: Garante que seu componente sempre exiba uma visão consistente do store externo, mesmo durante renderizações concorrentes. Isso previne problemas de "tearing", onde partes da sua UI podem mostrar dados inconsistentes.
- Desempenho: Otimizado para performance, minimizando re-renderizações desnecessárias. Ele aproveita os mecanismos internos do React para se inscrever eficientemente em mudanças e atualizar o componente apenas quando necessário.
- API Padronizada: Fornece uma API consistente e previsível para interagir com stores externos, independentemente da implementação subjacente.
- Redução de Boilerplate: Simplifica o processo de conexão com stores externos, reduzindo a quantidade de código personalizado que você precisa escrever.
- Compatibilidade: Funciona perfeitamente com uma vasta gama de fontes de dados externas e bibliotecas de gerenciamento de estado.
Como o useSyncExternalStore Funciona: Um Mergulho Profundo
O hook useSyncExternalStore recebe três argumentos:
subscribe(callback: () => void): () => void: Uma função que registra um callback para ser notificado quando o store externo muda. Ela deve retornar uma função para cancelar a inscrição. É assim que o React sabe quando o store tem novos dados.getSnapshot(): T: Uma função que retorna um snapshot dos dados do store externo. Este snapshot deve ser um valor simples e imutável que o React pode usar para determinar se os dados mudaram.getServerSnapshot?(): T(Opcional): Uma função que retorna o snapshot inicial dos dados no servidor. Isso é usado para renderização no lado do servidor (SSR) para garantir consistência entre o servidor e o cliente. Se não for fornecida, o React usarágetSnapshot()durante a renderização no servidor, o que pode não ser ideal para todos os cenários.
Aqui está um detalhamento de como esses argumentos funcionam juntos:
- Quando o componente é montado, o
useSyncExternalStorechama a funçãosubscribepara registrar um callback. - Quando o store externo muda, ele invoca o callback registrado através do
subscribe. - O callback informa ao React que o componente precisa ser re-renderizado.
- Durante a renderização, o
useSyncExternalStorechamagetSnapshotpara obter os dados mais recentes do store externo. - O React compara o snapshot atual com o anterior. Se forem diferentes, o componente é atualizado com os novos dados.
- Quando o componente é desmontado, a função de cancelamento de inscrição retornada pelo
subscribeé chamada para evitar vazamentos de memória.
Exemplo de Implementação Básica: Integrando com o localStorage
Vamos ilustrar como usar o useSyncExternalStore com um exemplo simples: lendo e escrevendo um valor no localStorage.
import { useSyncExternalStore } from 'react';
function getLocalStorageItem(key: string): string | null {
try {
return localStorage.getItem(key);
} catch (error) {
console.error("Erro ao acessar o localStorage:", error);
return null; // Lida com erros potenciais, como o `localStorage` estar indisponível.
}
}
function useLocalStorage(key: string): [string | null, (value: string) => void] {
const subscribe = (callback: () => void) => {
window.addEventListener('storage', callback);
return () => window.removeEventListener('storage', callback);
};
const getSnapshot = () => getLocalStorageItem(key);
const serverSnapshot = () => null; // Ou um valor padrão se for apropriado para sua configuração de SSR
const value = useSyncExternalStore(subscribe, getSnapshot, serverSnapshot);
const setValue = (newValue: string) => {
try {
localStorage.setItem(key, newValue);
// Dispara um evento de armazenamento na janela atual para acionar atualizações em outras abas.
window.dispatchEvent(new StorageEvent('storage', {
key: key,
newValue: newValue,
storageArea: localStorage,
} as StorageEventInit));
} catch (error) {
console.error("Erro ao definir o localStorage:", error);
}
};
return [value, setValue];
}
function MyComponent() {
const [name, setName] = useLocalStorage('name');
return (
<div>
<p>Olá, {name || 'Mundo'}</p>
<input
type="text"
value={name || ''}
onChange={(e) => setName(e.target.value)}
/>
</div>
);
}
export default MyComponent;
Explicação:
getLocalStorageItem: Uma função auxiliar para recuperar o valor dolocalStoragecom segurança, tratando possíveis erros.useLocalStorage: Um hook personalizado que encapsula a lógica para interagir com olocalStorageusandouseSyncExternalStore.subscribe: Ouve o evento'storage', que é acionado quando olocalStorageé modificado em outra aba ou janela. Crucialmente, nós disparamos um evento de armazenamento após definir um novo valor para acionar corretamente as atualizações na *mesma* janela.getSnapshot: Retorna o valor atual dolocalStorage.serverSnapshot: Retornanull(ou um valor padrão) para renderização no lado do servidor.setValue: Atualiza o valor nolocalStoragee dispara um evento de armazenamento para sinalizar outras abas.MyComponent: Um componente simples que usa o hookuseLocalStoragepara exibir e atualizar um nome.
Considerações Importantes para o localStorage:
- Tratamento de Erros: Sempre envolva o acesso ao
localStorageem blocostry...catchpara lidar com possíveis erros, como quando olocalStorageestá desabilitado ou indisponível (ex: no modo de navegação privada). - Eventos de Armazenamento: O evento
'storage'só é acionado quando olocalStorageé modificado em *outra* aba ou janela, não na mesma janela. Portanto, disparamos um novoStorageEventmanualmente após definir um valor. - Serialização de Dados: O
localStoragearmazena apenas strings. Você pode precisar serializar e desserializar estruturas de dados complexas usandoJSON.stringifyeJSON.parse. - Segurança: Tenha cuidado com os dados que você armazena no
localStorage, pois eles são acessíveis por código JavaScript no mesmo domínio. Informações sensíveis não devem ser armazenadas nolocalStorage.
Casos de Uso Avançados e Exemplos
1. Integrando com Zustand (ou outra biblioteca de gerenciamento de estado)
Integrar o useSyncExternalStore com uma biblioteca de gerenciamento de estado global como o Zustand é um caso de uso comum. Aqui está um exemplo:
import { useSyncExternalStore } from 'react';
import { create } from 'zustand';
interface BearState {
bears: number
increase: (by: number) => void
}
const useStore = create<BearState>((set) => ({
bears: 0,
increase: (by) => set((state) => ({ bears: state.bears + by }))
}))
function BearCounter() {
const bears = useSyncExternalStore(
useStore.subscribe,
useStore.getState,
() => ({ bears: 0, increase: () => {} }) // Snapshot do servidor, forneça o estado padrão
).bears
return <h1>{bears} ursos por aqui!</h1>
}
function Controls() {
const increase = useStore(state => state.increase)
return (<button onClick={() => increase(1)}>um urso</button>)
}
export { BearCounter, Controls }
Explicação:
- Estamos usando o Zustand para gerenciamento de estado global.
useStore.subscribe: Esta função se inscreve na store do Zustand e irá acionar re-renderizações quando o estado da store mudar.useStore.getState: Esta função retorna o estado atual da store do Zustand.- O terceiro parâmetro fornece um estado padrão para renderização no lado do servidor (SSR), garantindo que o componente renderize corretamente no servidor antes que o JavaScript do lado do cliente assuma.
- O componente obtém a contagem de ursos usando
useSyncExternalStoree a renderiza. - O componente
Controlsmostra como usar um setter do Zustand.
2. Integrando com Server-Sent Events (SSE)
useSyncExternalStore pode ser usado para atualizar componentes eficientemente com base em dados em tempo real de um servidor usando Server-Sent Events (SSE).
import { useSyncExternalStore, useState, useEffect, useCallback } from 'react';
function useSSE(url: string) {
const [data, setData] = useState(null);
const [eventSource, setEventSource] = useState(null);
useEffect(() => {
const newEventSource = new EventSource(url);
setEventSource(newEventSource);
newEventSource.onmessage = (event) => {
try {
const parsedData = JSON.parse(event.data);
setData(parsedData);
} catch (error) {
console.error("Erro ao analisar dados SSE:", error);
}
};
newEventSource.onerror = (error) => {
console.error("Erro SSE:", error);
};
return () => {
newEventSource.close();
setEventSource(null);
};
}, [url]);
const subscribe = useCallback((callback: () => void) => {
if (eventSource) {
eventSource.addEventListener('message', callback);
}
return () => {
if (eventSource) {
eventSource.removeEventListener('message', callback);
}
};
}, [eventSource]);
const getSnapshot = useCallback(() => data, [data]);
const serverSnapshot = useCallback(() => null, []);
const value = useSyncExternalStore(subscribe, getSnapshot, serverSnapshot);
return value;
}
function RealTimeDataComponent() {
const realTimeData = useSSE('/api/sse'); // Substitua pelo seu endpoint de SSE
if (!realTimeData) {
return <p>Carregando...</p>;
}
return <div><p>Dados em tempo real: {JSON.stringify(realTimeData)}</p></div>;
}
export default RealTimeDataComponent;
Explicação:
useSSE: Um hook personalizado que estabelece uma conexão SSE com uma URL fornecida.subscribe: Adiciona um ouvinte de eventos ao objetoEventSourcepara ser notificado sobre novas mensagens do servidor. Ele usauseCallbackpara garantir que a função de callback não seja recriada a cada renderização.getSnapshot: Retorna os dados mais recentes recebidos do stream SSE.serverSnapshot: Retornanullpara renderização no lado do servidor.RealTimeDataComponent: Um componente que usa o hookuseSSEpara exibir dados em tempo real.
3. Integrando com IndexedDB
Sincronize componentes React com dados armazenados no IndexedDB usando useSyncExternalStore.
import { useSyncExternalStore, useState, useEffect, useCallback } from 'react';
interface IDBData {
id: number;
name: string;
}
async function getAllData(): Promise {
return new Promise((resolve, reject) => {
const request = indexedDB.open('myDataBase', 1); // Substitua pelo nome e versão do seu banco de dados
request.onerror = (event) => {
console.error("Erro ao abrir o IndexedDB:", event);
reject(event);
};
request.onsuccess = (event) => {
const db = (event.target as IDBRequest).result as IDBDatabase;
const transaction = db.transaction(['myDataStore'], 'readonly'); // Substitua pelo nome da sua store
const objectStore = transaction.objectStore('myDataStore');
const getAllRequest = objectStore.getAll();
getAllRequest.onsuccess = (event) => {
const data = (event.target as IDBRequest).result as IDBData[];
resolve(data);
};
getAllRequest.onerror = (event) => {
console.error("Erro no getAll do IndexedDB:", event);
reject(event);
};
};
request.onupgradeneeded = (event) => {
const db = (event.target as IDBRequest).result as IDBDatabase;
db.createObjectStore('myDataStore', { keyPath: 'id' });
};
});
}
function useIndexedDBData(): IDBData[] | null {
const [data, setData] = useState(null);
const [dbInitialized, setDbInitialized] = useState(false);
useEffect(() => {
const initDB = async () => {
try{
await getAllData();
setDbInitialized(true);
} catch (e) {
console.error("Falha na inicialização do IndexedDB", e);
}
}
initDB();
}, []);
const subscribe = useCallback((callback: () => void) => {
// Aplica debounce ao callback para evitar re-renderizações excessivas.
let timeoutId: NodeJS.Timeout;
const debouncedCallback = () => {
clearTimeout(timeoutId);
timeoutId = setTimeout(callback, 50); // Ajuste o atraso do debounce conforme necessário
};
const handleVisibilityChange = () => {
// Busca os dados novamente quando a aba se torna visível
if (document.visibilityState === 'visible') {
debouncedCallback();
}
};
window.addEventListener('focus', debouncedCallback);
document.addEventListener('visibilitychange', handleVisibilityChange);
return () => {
window.removeEventListener('focus', debouncedCallback);
document.removeEventListener('visibilitychange', handleVisibilityChange);
clearTimeout(timeoutId);
};
}, []);
const getSnapshot = useCallback(() => {
// Busca os dados mais recentes do IndexedDB toda vez que getSnapshot é chamado
getAllData().then(newData => setData(newData));
return data;
}, [data]);
const serverSnapshot = useCallback(() => null, []);
return useSyncExternalStore(subscribe, getSnapshot, serverSnapshot);
}
function IndexedDBComponent() {
const data = useIndexedDBData();
if (!data) {
return <p>Carregando dados do IndexedDB...</p>;
}
return (
<div>
<h2>Dados do IndexedDB:</h2>
<ul>
{data.map((item) => (
<li key={item.id}>{item.name} (ID: {item.id})</li>
))}
</ul>
</div>
);
}
export default IndexedDBComponent;
Explicação:
getAllData: Uma função assíncrona que recupera todos os dados da store do IndexedDB.useIndexedDBData: Um hook personalizado que usauseSyncExternalStorepara se inscrever em mudanças no IndexedDB.subscribe: Configura ouvintes para mudanças de visibilidade e foco para atualizar os dados do IndexedDB e usa uma função de debounce para evitar atualizações excessivas.getSnapshot: Busca o snapshot atual chamando `getAllData()` e, em seguida, retornando os `dados` do estado.serverSnapshot: Retornanullpara renderização no lado do servidor.IndexedDBComponent: Um componente que exibe os dados do IndexedDB.
Considerações Importantes para o IndexedDB:
- Operações Assíncronas: As interações com o IndexedDB são assíncronas, então você precisa lidar cuidadosamente com a natureza assíncrona da recuperação e atualização de dados.
- Tratamento de Erros: Implemente um tratamento de erros robusto para lidar graciosamente com possíveis problemas de acesso ao banco de dados, como banco de dados não encontrado ou erros de permissão.
- Versionamento do Banco de Dados: Gerencie as versões do banco de dados com cuidado usando o evento
onupgradeneededpara garantir a compatibilidade dos dados à medida que sua aplicação evolui. - Desempenho: As operações do IndexedDB podem ser relativamente lentas, especialmente para grandes conjuntos de dados. Otimize consultas e indexação para melhorar o desempenho.
Considerações de Desempenho
Embora o useSyncExternalStore seja otimizado para desempenho, ainda há algumas considerações a ter em mente:
- Minimize as Mudanças de Snapshot: Garanta que a função
getSnapshotretorne um novo snapshot apenas quando os dados realmente mudaram. Evite criar novos objetos ou arrays desnecessariamente. Considere usar técnicas de memoização para otimizar a criação de snapshots. - Atualizações em Lote: Se possível, agrupe as atualizações no store externo para reduzir o número de re-renderizações. Por exemplo, se você está atualizando várias propriedades na store, tente atualizá-las todas em uma única transação.
- Debouncing/Throttling: Se o store externo muda com frequência, considere aplicar debounce ou throttle nas atualizações para o componente React. Isso pode evitar re-renderizações excessivas e melhorar o desempenho. Isso é especialmente útil com stores voláteis, como o redimensionamento da janela do navegador.
- Comparação Rasa (Shallow Comparison): Certifique-se de retornar valores primitivos ou objetos imutáveis em
getSnapshotpara que o React possa determinar rapidamente se os dados mudaram usando uma comparação rasa. - Atualizações Condicionais: Em casos onde o store externo muda frequentemente, mas seu componente só precisa reagir a certas mudanças, considere implementar atualizações condicionais dentro da função `subscribe` para evitar re-renderizações desnecessárias.
Armadilhas Comuns e Solução de Problemas
- Problemas de "Tearing": Se você ainda está enfrentando problemas de "tearing" após usar o
useSyncExternalStore, verifique novamente se sua funçãogetSnapshotestá retornando uma visão consistente dos dados e se a funçãosubscribeestá notificando corretamente o React sobre as mudanças. Garanta que você não está mutando os dados diretamente dentro da funçãogetSnapshot. - Loops Infinitos: Um loop infinito pode ocorrer se a função
getSnapshotsempre retornar um novo valor, mesmo quando os dados não mudaram. Isso pode acontecer se você estiver criando novos objetos ou arrays desnecessariamente. Certifique-se de que está retornando o mesmo valor se os dados não mudaram. - Ausência de Renderização no Lado do Servidor: Se você está usando renderização no lado do servidor, certifique-se de fornecer uma função
getServerSnapshotpara garantir que o componente renderize corretamente no servidor. Esta função deve retornar o estado inicial do store externo. - Cancelamento de Inscrição Incorreto: Sempre garanta que você cancele a inscrição do store externo corretamente dentro da função retornada pelo
subscribe. Falhar em fazer isso pode levar a vazamentos de memória. - Uso Incorreto com Modo Concorrente: Garanta que seu store externo seja compatível com o Modo Concorrente. Evite fazer mutações no store externo enquanto o React está renderizando. As mutações devem ser síncronas e previsíveis.
Conclusão
useSyncExternalStore é uma ferramenta poderosa para sincronizar componentes React com armazenamentos de dados externos. Ao entender como ele funciona e seguir as melhores práticas, você pode garantir que seus componentes exibam dados consistentes e atualizados, mesmo em cenários complexos de renderização concorrente. Este hook simplifica a integração com várias fontes de dados, desde bibliotecas de gerenciamento de estado de terceiros até APIs de navegador e streams de dados em tempo real, resultando em aplicações React mais robustas e performáticas. Lembre-se de sempre tratar possíveis erros, otimizar o desempenho e gerenciar cuidadosamente as inscrições para evitar armadilhas comuns.